上一篇我們認識了 Kotlin Coroutine(協程)的基本概念,知道可以使用 launch 啟動協程、透過 delay() 暫停工作。今天將繼續深入協程,學習多個 CoroutineScope、SupervisorJob、async/await、掛起函式與 Dispatcher 的使用方式,最後再加入 Kotlin 檔案建立、寫入與讀取操作。Kotlin Coroutines Guide
今天將會學習以下內容:
CoroutineScope 建立獨立的協程作用域。Dispatchers.Default 執行背景運算工作。delay() 與 Thread.sleep() 的差異。SupervisorJob 隔離不同協程的例外。async 建立有回傳值的協程。await() 取得 Deferred 的執行結果。suspend 掛起函式。withContext() 切換協程執行環境。File、FileWriter 與 FileReader 處理文字檔案。Kotlin Coroutine Basics
CoroutineScope 稱為「協程作用域」,主要用途是管理在這個作用域中啟動的協程。當我們使用 launch 或 async 啟動協程時,該協程會歸屬於特定的作用域。CoroutineScope API
建立協程作用域的基本語法如下:
val scope = CoroutineScope(Dispatchers.Default)
Dispatchers.Default 適合執行需要 CPU 運算的背景工作,例如資料排序、資料轉換或複雜計算。它會使用共享背景執行緒池執行工作。Coroutine Dispatchers
下面建立第一個 CoroutineScope,並使用 launch 啟動協程:
var scope1 = CoroutineScope(Dispatchers.Default)
scope1.launch {
repeat(3) {
println("第一個 Scope - 協程 :$it")
delay(300)
}
}
repeat(3) 會執行三次,變數 it 的值依序為 0、1、2。每次輸出後,使用 delay(300) 將協程暫停 300 毫秒。Kotlin repeat
delay() 只會暫停目前的協程,不會阻塞協程所在的執行緒。因此,在這段協程等待期間,相同執行緒還能繼續執行其他協程工作。Kotlin delay API
接著建立第二個獨立的協程作用域,並在其中呼叫 sFun():
val job = CoroutineScope(Dispatchers.Default)
job.launch {
sFun()
}
雖然這個變數命名為 job,但它實際儲存的是 CoroutineScope,不是 Job 物件。為了讓程式語意更加清楚,實務上可以將它改名為 scope2。CoroutineScope API
例如:
val scope2 = CoroutineScope(Dispatchers.Default)
scope2.launch {
sFun()
}
sFun() 前面使用 suspend 修飾,代表它是一個「掛起函式」:
suspend fun sFun() {
for (i in 1..5) {
println("${(0x41 + i).toChar()}")
Thread.sleep(500)
}
}
掛起函式只能在另一個掛起函式或協程內呼叫。它可以在不阻塞整個執行緒的情況下,暫停目前協程並等待工作完成。Composing Suspending Functions
程式中的 0x41 是十六進位整數,對應 Unicode 字元 A。因為迴圈從 1 開始,所以實際輸出的字元如下:
B
C
D
E
F
0x41 + 1 等於 0x42,轉換為字元後就是 B;後面的字元以此類推。Kotlin toChar
不過,掛起函式中不建議使用 Thread.sleep(500),因為它會阻塞目前執行緒。較好的方式是改成 delay(500):
suspend fun sFun() {
for (i in 1..5) {
println("${(0x41 + i).toChar()}")
// 使用 delay() 只暫停目前協程,不會阻塞背景執行緒。
delay(500)
}
}
資料來源:Kotlin delay API
主程式中還有一個一般的 for 迴圈:
for (count in 1..10) {
println("count = $count")
Thread.sleep(400)
}
這段程式執行於主執行緒。每輸出一次 count,就使用 Thread.sleep(400) 阻塞主執行緒 400 毫秒。Java Thread.sleep
因為前面的協程使用 Dispatchers.Default 在背景執行緒池中工作,所以主執行緒、第一個協程及第二個協程可能交錯輸出:
count = 1
第一個 Scope - 協程 :0
B
第一個 Scope - 協程 :1
count = 2
C
第一個 Scope - 協程 :2
count = 3
D
count = 4
E
count = 5
F
count = 6
count = 7
count = 8
count = 9
count = 10
因為協程由執行緒排程器安排工作,每次執行的時間點可能略有不同,所以實際輸出順序不一定完全相同。Coroutine Context and Dispatchers
接下來重新建立 scope1,並加入 SupervisorJob():
scope1 = CoroutineScope(
Dispatchers.Default + SupervisorJob()
)
一般情況下,父協程或其中一個子協程發生例外時,可能會取消同一工作階層中的其他協程。SupervisorJob 的作用是隔離子協程的失敗,讓其中一個子協程失敗時,不會自動取消其他同層子協程。SupervisorJob API
第一個工作故意產生例外:
scope1.launch {
delay(100)
println("Job 1")
error("job 1 fail")
}
error("job 1 fail") 會拋出 IllegalStateException,因此執行時會看到錯誤堆疊資訊。Kotlin error
第二個工作則正常執行:
scope1.launch {
delay(200)
println("job 2 is ok")
}
即使第一個協程發生例外,因為作用域中使用了 SupervisorJob,第二個協程仍然可以繼續執行並輸出:
Job 1
Exception in thread "DefaultDispatcher-worker-1"
java.lang.IllegalStateException: job 1 fail
job 2 is ok
這就是 SupervisorJob 最重要的功能:某個子工作失敗時,其他獨立工作仍能繼續執行。Kotlin Exception Handling
launch 適合執行不需要回傳結果的工作,它會回傳一個 Job 物件;async 適合執行需要回傳結果的工作,它會回傳 Deferred<T> 物件。Composing Suspending Functions
| 建立方式 | 回傳型別 | 適合用途 |
|---|---|---|
launch |
Job |
執行不需要回傳值的工作 |
async |
Deferred<T> |
執行需要回傳結果的工作 |
await() |
T |
等待並取得 async 的結果 |
資料來源:Kotlin async API
程式使用 runBlocking 建立一個協程作用域:
runBlocking {
val value1 = async {
delay(100)
println("async job 1")
100
}
val value2 = async {
delay(200)
println("async job 2")
200
}
println("waiting 3")
println("value1 = ${value1.await()}, value2 = ${value2.await()}")
println("sum = ${value1.await() + value2.await()}")
}
runBlocking 會阻塞目前的執行緒,直到區塊中的所有協程完成,因此常用於一般 Kotlin 主程式、測試程式或教學範例。runBlocking API
第一個 async 等待 100 毫秒後回傳 100;第二個 async 等待 200 毫秒後回傳 200:
val value1: Deferred<Int> = async {
delay(100)
100
}
val value2: Deferred<Int> = async {
delay(200)
200
}
此時 value1 與 value2 並不是整數,而是包裝非同步結果的 Deferred<Int>。必須呼叫 await() 才能等待並取得真正的數字。Deferred API
val result1 = value1.await()
val result2 = value2.await()
println("result1 = $result1")
println("result2 = $result2")
println("sum = ${result1 + result2}")
輸出結果如下:
waiting 3
async job 1
async job 2
value1 = 100, value2 = 200
sum = 300
資料來源:Kotlin await API
原本的範例使用:
println(
"value1 = ${value1.toString()}, value2 = ${value2.toString()}"
)
這樣輸出的不是 100 與 200,而可能是:
value1 = DeferredCoroutine{Completed},
value2 = DeferredCoroutine{Completed}
原因是 toString() 只會把 Deferred 物件本身轉成文字,顯示它的狀態;它不會取得協程內部的運算結果。Deferred API
正確做法是使用:
println(
"value1 = ${value1.await()}, value2 = ${value2.await()}"
)
資料來源:Kotlin await API
程式中另外定義了 doEvent() 與 doJob():
suspend fun doEvent() {
withContext(Dispatchers.Default) {
println("doEvent")
doJob()
delay(300)
println("event end")
}
}
withContext() 可以切換協程的執行環境,並等待區塊完成後再回到原來的協程環境。withContext API
doEvent() 使用 Dispatchers.Default,適合一般背景計算;它呼叫的 doJob() 則切換到 Dispatchers.IO:
suspend fun doJob() {
withContext(Dispatchers.IO) {
println("doJob")
delay(100)
println("job finished")
}
}
Dispatchers.IO 適合檔案讀寫、網路存取及資料庫操作等可能需要等待輸入輸出的工作。Dispatchers.IO API
如果在 main() 中加入以下程式:
runBlocking {
doEvent()
}
執行順序會是:
doEvent
doJob
job finished
event end
因為 doEvent() 必須等待 doJob() 完成,才能繼續執行後面的 delay(300) 與 println("event end")。withContext API
以下是本次協程範例的完整程式碼:
import kotlinx.coroutines.*
fun main() {
// =================================================================
// 1. 建立第一個獨立的協程作用域(變數名稱為 scope1)
// =================================================================
var scope1 = CoroutineScope(Dispatchers.Default)
// 在 scope1 中啟動第一個協程。
scope1.launch {
repeat(3) {
println("第一個 Scope - 協程 :$it")
// 暫停目前協程 300 毫秒,不阻塞背景執行緒。
delay(300)
}
}
// =================================================================
// 2. 建立第二個獨立的協程作用域
// =================================================================
val job = CoroutineScope(Dispatchers.Default)
job.launch {
// 呼叫下方定義的掛起函式。
sFun()
}
// =================================================================
// 3. 主執行緒迴圈
// =================================================================
for (count in 1..10) {
println("count = $count")
// 阻塞主執行緒 400 毫秒。
Thread.sleep(400)
}
// =================================================================
// 4. 使用 SupervisorJob 隔離不同子協程的例外
// =================================================================
scope1 = CoroutineScope(
Dispatchers.Default + SupervisorJob()
)
// 啟動工作 1,並故意引發例外。
scope1.launch {
delay(100)
println("Job 1")
// 故意拋出 IllegalStateException。
error("job 1 fail")
}
// 啟動工作 2。
// 因為使用 SupervisorJob,所以不會被工作 1 的失敗取消。
scope1.launch {
delay(200)
println("job 2 is ok")
}
// 暫停主執行緒,等待上面的背景協程執行。
Thread.sleep(1000)
println("waiting 2")
println()
// =================================================================
// 5. 使用 runBlocking、async 與 await
// =================================================================
runBlocking {
// async 會回傳 Deferred<Int>。
val value1 = async {
delay(100)
println("async job 1")
// 此區塊的回傳結果為 100。
100
}
// 第二個 async 同樣回傳 Deferred<Int>。
val value2 = async {
delay(200)
println("async job 2")
// 此區塊的回傳結果為 200。
200
}
println("waiting 3")
// 使用 await() 等待並取得真正的運算結果。
val result1 = value1.await()
val result2 = value2.await()
println("value1 = $result1, value2 = $result2")
println("sum = ${result1 + result2}")
println()
}
println()
// 暫停主執行緒,確認背景工作有足夠時間完成。
Thread.sleep(1000)
println("waiting 3")
}
/**
* 自訂掛起函式。
*
* 使用 suspend 修飾後,只能從協程或其他掛起函式中呼叫。
*/
suspend fun sFun() {
for (i in 1..5) {
// 0x41 是字元 A。
// 加上 1~5 後,依序輸出 B、C、D、E、F。
println("${(0x41 + i).toChar()}")
// 建議在掛起函式中使用 delay(),
// 避免 Thread.sleep() 阻塞整個背景執行緒。
delay(500)
}
}
/**
* 示範使用 Dispatchers.Default 執行一般背景工作。
*/
suspend fun doEvent() {
withContext(Dispatchers.Default) {
println("doEvent")
// 呼叫另一個掛起函式,並等待它執行完成。
doJob()
delay(300)
println("event end")
}
}
/**
* 示範使用 Dispatchers.IO 執行輸入輸出工作。
*/
suspend fun doJob() {
withContext(Dispatchers.IO) {
println("doJob")
delay(100)
println("job finished")
}
}
Kotlin 可以使用 Java 的 File 類別處理檔案與資料夾:
val dir = File("").absolutePath
println("current path: $dir")
File("") 代表目前的工作目錄,absolutePath 會取得它的絕對路徑。Java File API
以下程式會在 Windows 的 C:\ 建立 kotlin 資料夾:
val newDir = File("c:\\kotlin")
val result = newDir.mkdir()
println("result = $result")
因為 Kotlin 字串中的反斜線 \ 是跳脫字元,所以 Windows 路徑必須寫成 c:\\kotlin。mkdir() 成功建立資料夾時回傳 true;如果資料夾已存在或建立失敗,則回傳 false。Java File.mkdir
也可以使用原始字串,讓 Windows 路徑更容易閱讀:
val newDir = File("""C:\kotlin""")
Kotlin 的三個雙引號可以建立原始字串,裡面的反斜線不需要再次跳脫。Kotlin Strings
使用 File.apply 可以在建立物件後,連續執行多個檔案操作:
val file = File("c:\\kotlin\\data1.txt").apply {
createNewFile()
writeText("Hello Mary \n")
appendText("Good morning !\n")
appendText("This is kotlin class. \n")
}
各個函式的用途如下:
| 函式 | 用途 |
|---|---|
createNewFile() |
建立新檔案 |
writeText() |
寫入文字,原有內容會被覆蓋 |
appendText() |
將文字附加在檔案末端 |
readText() |
一次讀取整個文字檔案 |
readLines() |
將每一行讀成字串串列 |
使用 readText() 可以一次取得檔案內的全部文字:
val data = file.readText()
println(data)
輸出如下:
Hello Mary
Good morning !
This is kotlin class.
readText() 適合讀取容量較小的文字檔案。如果檔案非常大,應改用緩衝串流或逐行讀取,以免一次占用大量記憶體。Kotlin readText
使用 readLines() 可以將檔案內容轉成 List<String>:
val lines = file.readLines()
lines.forEach { line ->
println("data = $line")
}
輸出如下:
data = Hello Mary
data = Good morning !
data = This is kotlin class.
資料來源:Kotlin readLines
除了 Kotlin 提供的 writeText(),也可以使用 Java 的 FileWriter:
val newFile = FileWriter(
"c:\\kotlin\\data2.txt",
false
)
第二個參數表示是否採用附加模式:
| 參數 | 寫入方式 |
|---|---|
false |
覆蓋原有內容 |
true |
將內容附加在檔案末端 |
資料來源:Java FileWriter API
檔案寫入完成後必須關閉 FileWriter,否則可能造成資料尚未完整寫入,或檔案持續被程式鎖定。Kotlin 可以使用 use 自動關閉資源:
newFile.use {
it.write(0x31)
it.write(0x38)
it.write(0x41)
it.write("\n")
val data = charArrayOf(
'H', 'e', 'l', 'l', 'o', '\n'
)
it.write(data)
it.write("It is sunny day\n")
}
use 的概念與 Java 的 try-with-resources 類似。不論區塊正常完成或發生例外,都會自動呼叫資源的 close()。Kotlin use
其中:
it.write(0x31)
it.write(0x38)
it.write(0x41)
寫入的是 Unicode 字元值:
| 十六進位 | 十進位 | 字元 |
|---|---|---|
0x31 |
49 | 1 |
0x38 |
56 | 8 |
0x41 |
65 | A |
資料來源:Unicode Latin Basic
因此,data2.txt 的內容會是:
18A
Hello
It is sunny day
下面建立長度為 50 的 CharArray,作為檔案讀取緩衝區:
val chBuff = CharArray(50)
var len = 0
var myFile = FileReader("c:\\kotlin\\data2.txt")
接著使用 read(chBuff) 把文字讀入字元陣列:
myFile.use {
len = it.read(chBuff)
println("len = $len")
}
read() 會回傳實際讀到的字元數。如果已經到達檔案結尾,則會回傳 -1。Java Reader.read
讀取完成後,可以將緩衝區中的有效範圍轉成字串:
println(String(chBuff, 0, len))
三個參數分別代表:
chBuff:來源字元陣列。0:開始位置。len:要轉換的字元數量。也可以使用 for 迴圈逐字輸出:
for (i in 0 until len) {
print(chBuff[i])
}
println()
0 until len 會建立從 0 到 len - 1 的整數範圍,可避免存取到字元陣列有效內容以外的位置。Kotlin Ranges
import java.io.File
import java.io.FileReader
import java.io.FileWriter
fun main() {
// ================================================================
// 1. 取得並輸出目前專案的執行路徑(工作目錄)
// ================================================================
val dir = File("").absolutePath
println("current path: $dir")
// ================================================================
// 2. 在 Windows 的 C 槽建立 kotlin 資料夾
// ================================================================
val newDir = File("c:\\kotlin")
// mkdir() 成功建立資料夾時回傳 true。
// 若資料夾已存在或建立失敗,則回傳 false。
val result = newDir.mkdir()
println("result = $result")
// ================================================================
// 3. 使用 Kotlin 擴充函式操作 data1.txt
// ================================================================
val file = File("c:\\kotlin\\data1.txt").apply {
// 建立新檔案。
createNewFile()
// 寫入文字;如果檔案原本有內容,會覆蓋舊內容。
writeText("Hello Mary \n")
// 將文字附加到檔案末端。
appendText("Good morning !\n")
// 再次附加一行文字。
appendText("This is kotlin class. \n")
}
println()
// ================================================================
// 4. 使用 readText() 一次讀取完整內容
// ================================================================
val data = file.readText()
println(data)
println()
// ================================================================
// 5. 使用 readLines() 按行讀取內容
// ================================================================
val lines = file.readLines()
lines.forEach { line ->
println("data = $line")
}
println()
// ================================================================
// 6. 使用 FileWriter 寫入 data2.txt
// ================================================================
// FileWriter 的第二個參數代表附加模式:
// false:覆蓋寫入。
// true:附加寫入。
val newFile = FileWriter(
"c:\\kotlin\\data2.txt",
false
)
// use 相當於 Java 的 try-with-resources。
// 區塊結束後會自動關閉 FileWriter,釋放檔案資源。
newFile.use { writer ->
// write(Int) 會將數值當成 Unicode 字元碼寫入。
writer.write(0x31) // 字元 1
writer.write(0x38) // 字元 8
writer.write(0x41) // 字元 A
writer.write("\n") // 換行
// 建立字元陣列。
val charData = charArrayOf(
'H',
'e',
'l',
'l',
'o',
'\n'
)
// 寫入完整字元陣列。
writer.write(charData)
// 直接寫入字串。
writer.write("It is sunny day\n")
}
// ================================================================
// 7. 使用 readText() 快速讀取 data2.txt
// ================================================================
val readData = File(
"c:\\kotlin\\data2.txt"
).readText()
println(readData)
// ================================================================
// 8. 使用 FileReader 與字元陣列讀取檔案
// ================================================================
// 建立可存放 50 個字元的緩衝區。
val chBuff = CharArray(50)
// 記錄實際讀取到的字元數量。
var len = 0
var myFile = FileReader(
"c:\\kotlin\\data2.txt"
)
myFile.use { reader ->
// 將資料讀入 chBuff。
// 回傳值是實際讀取到的字元數量。
len = reader.read(chBuff)
println("len = $len")
}
// ================================================================
// 9. 輸出字元陣列的有效內容
// ================================================================
// 方法 A:
// 從索引 0 開始,將 len 個字元轉換為 String。
if (len > 0) {
println(String(chBuff, 0, len))
}
println()
// 方法 B:
// 使用迴圈逐字輸出。
if (len > 0) {
for (i in 0 until len) {
print(chBuff[i])
}
}
println()
// ================================================================
// 10. 使用 FileReader 的 readLines() 按行讀取
// ================================================================
myFile = FileReader(
"c:\\kotlin\\data2.txt"
)
myFile.use { reader ->
// readLines() 是 Kotlin 為 Reader 提供的擴充函式。
val dataList = reader.readLines()
for (lineData in dataList) {
println(lineData)
}
}
}
資料來源:Kotlin I/O API
當檔案內容比較大,或檔案位於速度較慢的儲存裝置時,讀寫操作不應放在主執行緒執行。可以使用 withContext(Dispatchers.IO) 將檔案操作切換到 I/O 執行緒池。Dispatchers.IO API
以下是協程搭配檔案寫入的完整範例:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.io.File
fun main() = runBlocking {
// 呼叫掛起函式並等待檔案寫入完成。
saveData()
// 呼叫掛起函式並取得檔案內容。
val content = readData()
println("檔案內容:")
println(content)
}
/**
* 使用 Dispatchers.IO 寫入檔案。
*/
suspend fun saveData() {
withContext(Dispatchers.IO) {
val directory = File("c:\\kotlin")
// 如果資料夾不存在,就建立資料夾及必要的上層目錄。
if (!directory.exists()) {
directory.mkdirs()
}
val file = File(
directory,
"coroutine_data.txt"
)
// writeText() 會覆蓋原本的檔案內容。
file.writeText(
"Kotlin Coroutine\n" +
"File I/O with Dispatchers.IO\n"
)
println("檔案寫入完成:${file.absolutePath}")
}
}
/**
* 使用 Dispatchers.IO 讀取檔案。
*/
suspend fun readData(): String {
return withContext(Dispatchers.IO) {
val file = File(
"c:\\kotlin\\coroutine_data.txt"
)
// 先判斷檔案是否存在,避免直接讀取不存在的檔案。
if (file.exists()) {
file.readText()
} else {
"找不到檔案:${file.absolutePath}"
}
}
}
資料來源:Coroutine Context and Dispatchers
今天的範例同時涵蓋 Kotlin 協程與檔案操作。CoroutineScope 用來管理協程生命週期;launch 適合沒有回傳值的工作;async 適合需要回傳結果的工作,而結果必須透過 await() 取得。Kotlin Coroutines Guide
SupervisorJob 可以避免單一子協程失敗時取消其他同層協程,適合多個彼此獨立的背景工作。例如 App 同時更新使用者資料、載入圖片與取得通知時,其中一個工作失敗,不一定要讓其他工作全部停止。SupervisorJob API
在掛起函式中,等待時間應優先使用 delay(),避免使用會阻塞執行緒的 Thread.sleep()。如果需要處理檔案、網路或資料庫,則可以透過 withContext(Dispatchers.IO) 切換到適合 I/O 工作的執行緒池。Kotlin delay API
檔案處理方面,Kotlin 提供 writeText()、appendText()、readText() 與 readLines() 等簡潔的擴充函式;若需要更細部地控制讀寫,也可以使用 FileWriter、FileReader 和字元緩衝區。Kotlin File API
透過今天的練習,我們已經能夠同時執行多個背景工作、隔離協程例外、取得非同步運算結果,並將資料寫入檔案。下一步就可以把這些技術運用在 Android App,例如背景下載、API 資料取得、Room 資料庫操作,以及本機檔案儲存。Android Kotlin Coroutines